JVM

JVM Java内存模型与线程

Posted by 余腾 on 2019-04-12
Estimated Reading Time 12 Minutes
Words 3.6k In Total
Viewed Times

本篇介绍虚拟机如何实现多线程、多线程之间由于共享和竞争数据而导致的一系列问题及解决方案。

内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

  • 概述
  • Java内存模型
  • Java与线程

一、概述

多任务处理的必要性:

  • 1、充分利用计算机处理器的能力,避免处理器在磁盘I/O、网络通信或数据库访问时总是处于等待其他资源的状态。
  • 2、便于一个服务端同时对多个客户端提供服务。通过指标TPS(Transactions Per Second)可衡量一个服务性能的高低好坏。
    • 它表示每秒服务端平均能响应的请求总数,进而体现出程序的并发能力。

硬件的效率与一致性:

  • 为了更好的理解Java内存模型,先理解物理计算机中的并发问题,两者有很高的可比性。

高速缓存

由于计算机的存储设备与处理器的运算速度有几个数量级的差距。所以现代计算机系统都不得加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:

  • 将运算需要使用到的数据复制到缓存中,让运算能快速进行,
  • 当运算结束后再从缓存同步回内存之中,而无须让处理器等待缓慢的内存读写。

但是基于高速缓存的存储交互在多处理器系统中会带来缓存一致性(Cache Coherence)的问题。
这是因为每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),当多个处理器的运算任务都涉及同一块主内存区域时,就可能导致各自的缓存数据不一致。解决办法就是需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

如下图:


内存模型

在本篇提到的“内存模型”可以理解为

在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

  • 不同架构的物理机可以拥有不同的内存模型,Java虚拟机也有自己的内存模型。


二、Java内存模型

目的:屏蔽掉各种硬件和操作系统的内存访问差异,实现Java程序在各种平台下都能达到一致的内存访问效果。

主要目标:通过定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

  • 注意:这里的变量与Java编程中说的变量有所区别,它包括了 实例字段、静态字段 和 构成数组对象的元素。但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java内存模型规定

主内存(Main Memory):所有的变量都存储在主内存中。直接对应物理硬件的内存。

  • 这里的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,两者基本上是没有关系的。

工作内存(Working Memory):每条线程还有自己的工作内存,用于保存被该线程使用到的变量的主内存副本拷贝。

  • 线程对变量所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中变量。
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均必须需要通过主内存来完成。
  • 为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中。

内存间交互操作

交互协议:

  • 规定一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。

交互八种操作

锁定(lock): 作用于主内存的变量,把一个变量标识为一条线程独占的状态。
解锁(unlock): 作用于主内存的变量,把处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
读取(read): 作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
载入(load): 作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
使用(use): 作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。
赋值(assign): 作用于工作内存的变量,把从执行引擎接收到的值赋给工作内存的变量。
存储(store): 作用于工作内存的变量,把工作内存中变量的值传送到主内存中,以便随后的write操作使用。
写入(write): 作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存变量中。

结论:注意是顺序非连续。

  • 如果要把变量从主内存复制到工作内存,那就要顺序地执行 read 和 load。
  • 如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write。

确保并发操作安全的原则,在Java内存模型中规定了执行上述8种基本操作时需要满足如下规则:

  • 1、不允许read和load、store和write操作之一单独出现。即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 3、不允许一个线程无原因地,即没有发生过任何assign操作就把数据从线程的工作内存同步回主内存中。
  • 4、一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,即对一个变量实施use、store操作之前必须先执行过了assign和load操作。
  • 5、一个变量在同一个时刻只允许一条线程对其进行lock操作。但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 6、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 7、如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。

可见这么多规则相当严谨但又十分繁琐,实践起来非常麻烦。
下面👇介绍一个等效判断原则:先行发生原则

先行发生原则

先行发生原则是Java内存模型中定义的两项操作之间的偏序关系。它是判断数据是否存在竞争、线程是否安全的主要依据。下面例举一些“天然的”先行发生关系,无须任何同步器协助就已经存在,可以在编码中直接使用。

  • 程序次序规则(Program Order Rule): 在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

  • 管程锁定规则(Monitor Lock Rule): 一个unlock操作先行发生于后面对同一个锁的lock操作。

  • volatile变量规则(Volatile Variable Rule): 对一个volatile变量的写操作先行发生于后面对这个变量的读操作。

  • 线程启动规则(Thread Start Rule): Thread对象的start()先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule): 线程中的所有操作都先行发生于对此线程的终止检测。

    • 可通过Thread.join()结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule): 对线程interrupt()的调用先行发生于被中断线程的代码检测到中断事件的发生。

    • 可通过Thread.interrupted()检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule): 一个对象的初始化完成先行发生于它的finalize()的开始。

  • 传递性(Transitivity): 如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A一定先行发生于操作C。



原子性、可见性、有序性

原子性(Atomicity):一个操作要么都执行要么都不执行。

  • 可直接保证的原子性变量操作有:read、load、assign、use、store和write,因此可认为基本数据类型的访问读写是具备原子性的。

可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

  • 通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现。
  • 提供三个关键字保证可见性:
    • volatile 能保证新值能立即同步到主内存,且每次使用前立即从主内存刷新;
    • synchronized对一个变量执行unlock操作之前可以先把此变量同步回主内存中;
    • final 修饰的字段在构造器中一旦初始化完成且构造器没有把this的引用传递出去,就可以在其他线程中就能看见final字段的值。

有序性(Ordering):程序代码按照指令顺序执行。

  • 如果在本线程内观察,所有的操作都是有序的,指“线程内表现为串行的语义”;
  • 如果在一个线程中观察另一个线程,所有的操作都是无序的,指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
    • 提供两个关键字保证有序性:
    • volatile 本身就包含了禁止指令重排序的语义;
    • synchronized保证一个变量在同一个时刻只允许一条线程对其进行lock操作,使得持有同一个锁的两个同步块只能串行地进入。


三、Java与线程

线程实现的三种方式

1、使用内核线程(Kernel-Level Thread,KLT)(一对一线程模型)
2、使用用户线程(User Thread,UT)(一对多线程模型)
3、使用用户线程加轻量级进程混合(多对多线程模型)


Java线程调度的两种方式

线程调度:指系统为线程分配处理器使用权的过程。

1、协同式线程调度(Cooperative Threads-Scheduling)

  • 由线程本身来控制线程的执行时间。线程把自己的工作执行完后,要主动通知系统切换到另外一个线程上。
  • 优点: 实现简单;切换操作自己可知,不存在线程同步的问题。
  • 缺点: 线程执行时间不可控,假如一个线程编写有问题一直不告知系统进行线程切换,那么程序就会一直被阻塞。

2、抢占式线程调度(Preemptive Threads-Scheduling)

  • 由系统来分配每个线程的执行时间。
  • 优点: 线程执行时间是系统可控的,不存在一个线程导致整个进程阻塞的问题。
  • 可以通过设置线程优先级,优先级越高的线程越容易被系统选择执行。
    • 但是线程优先级并不是太靠谱:
    • 1、优先级可能会被系统自行改变。
    • 2、因为Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,在一些平台上不同的优先级实际会变得相同;

线程的五种状态

在任意一个时间,一个线程只能有且只有其中的一种状态


1、新建(New):线程创建后尚未启动。

2、运行(Runable):包括正在执行(Running)和等待CPU为它分配执行时间(Ready)两种。

3、无限期等待(Waiting):该线程不会被分配CPU执行时间,要等待被其他线程显式地唤醒。

  • 以下方法会让线程陷入无限期等待状态:
    • 没有设置Timeout参数的Object.wait()
    • 没有设置Timeout参数的Thread.join()
    • LockSupport.park()

4、限期等待(Timed Waiting):该线程不会被分配CPU执行时间,但在一定时间后会被系统自动唤醒。

  • 以下方法会让线程进入限期等待状态:
    • Thread.sleep()
    • 设置了Timeout参数的Object.wai()
    • 设置了Timeout参数的Thread.join()
    • LockSupport.parkNanos()
    • LockSupport.parkUntil()

5、阻塞(Blocked):线程被阻塞。

注意区别:

  • 阻塞状态:在等待获取到一个排他锁,在另外一个线程放弃这个锁的时候发生;
  • 等待状态:在等待一段时间或者唤醒动作的发生,在程序等待进入同步区域的时候发生。

6、结束(Terminated):线程已经结束执行。


线程状态之间的转换图:

参考

厘米姑娘-要点提炼| 理解JVM之内存模型&线程

感谢阅读


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them. Thank you !